5.09. Синтаксис Kotlin
Синтаксис Kotlin
Kotlin — язык статической типизации, разработанный компанией JetBrains с 2011 года и официально признанный Google основным языком для Android-разработки в 2017 году. Его синтаксис формировался под влиянием широкого спектра языков: Java (совместимость и экосистема), Scala (мощные абстракции), C# (свойства, делегаты, асинхронность), Python и Groovy (лаконичность, читаемость) — однако итоговый результат не является простым миксом. Вместо этого Kotlin последовательно реализует принцип практической выразительности: каждая синтаксическая единица решает конкретную инженерную задачу, устраняя избыточность без жертвования надёжностью и производительностью.
Синтаксис Kotlin — рефакторинг парадигмы: замена шаблонных идиом, склонных к ошибкам (например, null-проверки, шаблонные классы данных, повторяющиеся шаблоны инициализации), на встроенные, проверяемые на этапе компиляции конструкции. Лаконичность достигается не за счёт умолчаний, ведущих к неопределённости, а за счёт вывода типов, интеллектуального умолчания, структурированной неизменяемости и семантически обогащённой грамматики.
Ниже рассматриваются ключевые аспекты синтаксиса — от базовых деклараций до продвинутых идиом — с акцентом на почему так, а не только как.
1. Объявление переменных
В Kotlin отсутствует ключевое слово final, привычное по Java. Вместо этого используется двухуровневая система деклараций:
val(от value) — объявляет неизменяемую ссылку.var(от variable) — объявляет изменяемую ссылку.
Это сознательный дизайн-решение, направленное на смещение баланса в сторону иммутабельности как стандартного подхода. Переменная, объявленная через val, не может быть переприсвоена — ни явно (val x = 5; x = 6 — ошибка компиляции), ни неявно (например, в цикле for). При этом объект, на который ссылается val, может быть изменяемым — например, val list = mutableListOf(1, 2, 3); list.add(4) допустимо. То есть val фиксирует саму ссылку, а не состояние объекта.
Тип может быть указан явно (val name: String = "Alice"), но в подавляющем большинстве случаев компилятор выводит его из инициализатора (val age = 30 → Int). Это не «динамическая типизация» — тип фиксируется на этапе компиляции, и попытка присвоить age = "thirty" вызовет ошибку. Вывод типов устраняет избыточность без потери строгой проверки.
Примечательно, что Kotlin допускает отложенную инициализацию (lateinit var) и делегированные свойства (by lazy, by Delegates.observable), но эти механизмы явно маркируются и требуют осознанного использования — в отличие от неявной отложенной инициализации в Java (где ссылка по умолчанию null), что является частой причиной NullPointerException.
2. Функции
Функции в Kotlin объявляются ключевым словом fun. Синтаксис включает:
- Имя функции;
- Список параметров в круглых скобках, каждый с именем и типом (обязательно);
- Необязательный возвращаемый тип после двоеточия;
- Тело — либо блок
{}, либо одно выражение после=.
fun greet(name: String): Unit {
println("Hello, $name")
}
Обратите внимание на возвращаемый тип Unit. Это аналог void в Java, но с принципиальным отличием: Unit — это реальный тип, имеющий ровно одно значение — Unit. Это позволяет трактовать все функции как возвращающие значение, что упрощает обобщённое программирование (например, в обобщённых контекстах, где требуется возвращаемый тип). При этом при записи Unit можно опустить — компилятор подставит его автоматически, если тело — блок без явного return.
Если функция состоит из одного выражения, её можно записать в выражении-функции (expression body):
fun square(x: Int) = x * x
Здесь возвращаемый тип выводится как Int. Такая форма особенно удобна для простых преобразований, делегирования и extension-функций.
Параметры функций всегда имеют имена и типы. Передача по имени (greet(name = "Alice")) поддерживается, что повышает читаемость при вызове с несколькими параметрами одинакового типа.
3. Строка и интерполяция
В Kotlin строки поддерживают интерполяцию — подстановку значений выражений непосредственно в текстовую константу:
val name = "Alice"
println("Hello, $name") // → Hello, Alice
println("Length: ${name.length}") // → Length: 5
Символ $ не является строковым макросом или препроцессорной конструкцией — это часть лексического анализа строковых литералов. После $ допускается либо простое имя переменной, либо произвольное выражение в фигурных скобках. Это безопаснее, чем конкатенация ("Hello, " + name), так как исключает случайное пропускание пробела или непарных кавычек, и выразительнее — особенно при вложенных структурах.
Строки в Kotlin неизменяемы (String — финальный класс), как и в Java, но стандартная библиотека предоставляет богатый набор функций-расширений (trim(), split(), replace(), lines(), take(), drop(), регулярные выражения и др.), что делает работу с ними близкой по удобству к Python.
4. Null Safety
Одна из самых значимых инноваций Kotlin — система null-безопасности, реализованная на уровне системы типов. В отличие от Java, где любой объектный тип может принимать значение null неявно, Kotlin разделяет типы на не nullable и nullable:
String— не может бытьnull;String?— может бытьnull.
Это изменение кардинально: компилятор запрещает вызов методов или доступ к свойствам nullable-типа без явной проверки. Существует несколько стандартных способов безопасной работы с nullable-значениями:
4.1. Оператор безопасного вызова ?.
val length = nullableName?.length // Int? — будет null, если nullableName == null
Если левый операнд null, выражение возвращает null, не вызывая исключения.
4.2. Оператор элвиса ?:
Позволяет задать значение по умолчанию:
val len = nullableName?.length ?: 0
println(nullableName?.length ?: "No name")
Выражение справа от ?: вычисляется только если левое — null. Это аналог Optional.orElse() в Java, но без накладных расходов на обёртку.
4.3. Утверждение non-null !!
val len = nullableName!!.length // NullPointerException, если null
Используется сознательно — когда разработчик гарантирует ненулевое значение, а компилятор не может это проверить (например, при взаимодействии с Java-кодом). Это сигнал о ручной ответственности.
4.4. Безопасное приведение и проверки с is, as?
if (obj is String) {
println(obj.length) // obj автоматически приведён к String в этом блоке
}
val str = obj as? String // возвращает String? или null
Это устраняет необходимость в повторных проверках и явных приведениях, сокращая шаблонный код.
Важно: система null-безопасности работает статически. Ошибки вида «возможно null» обнаруживаются на этапе компиляции, а не во время выполнения. Это не абсолютная гарантия (например, при работе с Java-кодом без @Nullable/@NonNull аннотаций), но радикально снижает долю NPE в «чистом» Kotlin-коде.
5. Data классы
В Java класс, представляющий данные (например, User), требует ручной реализации или генерации equals(), hashCode(), toString(), copy(), геттеров/сеттеров. В Kotlin достаточно:
data class User(val id: Int, val name: String)
Ключевое слово data автоматически генерирует:
equals()иhashCode()на основе всех свойств в первичном конструкторе;toString()видаUser(id=1, name=Alice);copy()— метод для создания модифицированной копии:
val user2 = user1.copy(name = "Bob");- компонентные функции
component1(),component2()— для деструктурирования:
val (id, name) = user.
Это не магия — код генерируется на этапе компиляции и доступен в байткоде. Data-классы не могут быть абстрактными, открытыми (open), наследоваться от других классов (кроме Any) и не должны объявлять дополнительные свойства в теле, влияющие на семантику равенства (хотя технически могут — с предостережением компилятора).
Data-классы — инструмент для транспортных и хранящих структур: DTO, модели, конфигурационные записи. Они фокусируются на идентичности по содержимому, а не по ссылке.
6. Extension-функции и свойства
Одна из наиболее мощных возможностей Kotlin — расширение существующих классов без изменения их исходного кода и без наследования. Это достигается через extension-функции и extension-свойства:
fun String.addExclamation() = this + "!"
val String.lastChar: Char get() = this.last()
Эти объявления компилируются в статические методы (в случае JVM — в static final методы класса-хоста), принимающие экземпляр расширяемого типа как первый параметр (в данном случае — this). При вызове:
"Hello".addExclamation() // → "Hello!"
println("World".lastChar) // → 'd'
— создаётся иллюзия, что метод/свойство принадлежит классу, хотя на уровне JVM это обычный вызов статического метода.
Важные ограничения и особенности:
- Extension-функция не имеет доступа к
privateилиprotectedчленам расширяемого класса — только к публичному API. - При наличии конфликта имён (например, extension-функция и метод класса с одинаковой сигнатурой), приоритет имеет метод класса.
- Extensions не наследуются — они привязаны к статическому типу переменной, а не к её динамическому типу:
open class A
class B : A()
fun A.foo() = "A"
fun B.foo() = "B"
val b: A = B()
println(b.foo()) // → "A", несмотря на то, что b — экземпляр B
Это следствие статической диспетчеризации — и оно предсказуемо.
Extensions позволяют:
- Организовывать вспомогательный код по семантическим модулям, а не по иерархии наследования;
- Создавать DSL (Domain-Specific Languages), например, для конфигурации, тестирования, построения UI;
- Улучшать API сторонних библиотек без wrapper-классов.
7. Управление потоком выполнения
В Kotlin практически все управляющие конструкции — выражения, возвращающие значение. Это устраняет необходимость в отдельных переменных-аккумуляторах и делает код более декларативным.
7.1. if как выражение
В отличие от Java, где if — это исключительно оператор, в Kotlin if всегда возвращает значение — результат последнего выражения в выбранной ветке:
val max = if (a > b) a else b
Каждая ветка (then, else) должна быть выражением одного типа (или Unit). Пустой блок не допускается — это исключает ошибки вида «забыл else». При многострочных ветках используется блочная форма:
val description = if (score >= 90) {
println("Высокий результат")
"Отлично"
} else if (score >= 60) {
"Удовлетворительно"
} else {
"Неудовлетворительно"
}
Компилятор проверяет, что все ветви возвращают совместимые типы. Если одна ветвь — String, а другая — Int, будет ошибка. Это гарантирует согласованность логики на уровне типов.
7.2. when — обобщённое сопоставление с образцом
when заменяет switch из Java, но значительно мощнее. Он поддерживает:
- Сопоставление по значению (включая диапазоны, списки, условия);
- Проверку типов (
is); - Выражения в ветках;
- Использование как выражения и как оператора.
Примеры:
val result = when (x) {
0 -> "Ноль"
in 1..10 -> "Маленькое число"
!in 11..100 -> "Вне диапазона"
is String -> "Строка длиной ${x.length}"
else -> "Что-то другое"
}
Ветки проверяются сверху вниз; выполнение останавливается на первой совпавшей. Нет необходимости в break — это исключает «проваливание» (fall-through), характерное для Java.
when может использоваться и без аргумента — тогда каждая ветка содержит логическое выражение:
when {
x < 0 -> println("Отрицательное")
x == 0 -> println("Ноль")
else -> println("Положительное")
}
Это эквивалент if-else if-else, но в более структурированной форме — особенно удобно при большом числе условий.
Важно: when должен быть исчерпывающим в контексте выражения — либо охватывать все возможные значения (например, все значения enum), либо содержать else. Компилятор проверяет полноту покрытия для sealed class и enum, что делает when ключевым инструментом в функционально-ориентированном стиле.
7.3. Циклы: for, while, do-while и range-based итерации
Цикл for в Kotlin унифицирован и работает только по итерируемым (Iterable<T>) или диапазонам (ClosedRange<T>). Нет синтаксиса for (int i = 0; ...) — вместо этого используются:
- Диапазоны:
1..10(включительно),1 until 10(исключая 10),10 downTo 1,1..10 step 2; - Функции-расширения:
indices,withIndex().
Примеры:
for (i in 1..5) println(i) // 1, 2, 3, 4, 5
for (i in 1 until 5) println(i) // 1, 2, 3, 4
for (i in 5 downTo 1) println(i) // 5, 4, 3, 2, 1
for (i in 1..10 step 3) println(i) // 1, 4, 7, 10
val list = listOf("a", "b", "c")
for (item in list) println(item)
for ((index, value) in list.withIndex()) {
println("$index: $value")
}
Циклы while и do-while сохраняют привычную семантику, но их использование поощряется только при условных итерациях, где нельзя предсказать число шагов (например, чтение из потока).
Диапазоны (IntRange, CharRange, LongRange) — это полноценные классы с методами (contains, isEmpty, first, last), реализующие Iterable. Они работают с любыми типами, реализующими Comparable, а также поддерживают пользовательские шаги через step.
8. Функции высшего порядка и лямбды
Kotlin поддерживает функции высшего порядка — функции, принимающие другие функции в качестве параметров или возвращающие их. Это фундамент асинхронного, реактивного и функционального стилей программирования.
8.1. Типы функций
Тип функции в Kotlin записывается как (T1, T2, ...) -> R, где T1... — типы параметров, R — возвращаемый тип.
Примеры:
() -> Unit— функция без параметров и возврата (аналогRunnable);(Int) -> String— функция, принимающаяIntи возвращающаяString;(String, String) -> Boolean— компаратор.
Такой тип может использоваться как аннотация параметра:
fun performOperation(x: Int, y: Int, operation: (Int, Int) -> Int): Int {
return operation(x, y)
}
val sum = performOperation(3, 4) { a, b -> a + b }
8.2. Лямбда-выражения
Лямбда — анонимная функция, записываемая в фигурных скобках:
{ x: Int -> x * x }
{ a, b -> a + b }
{ println("Hello") }
Особенности:
- Типы параметров можно опустить, если они выводятся из контекста (как в примере выше);
- Последнее выражение — возвращаемое значение;
- Лямбда может захватывать переменные из окружающей области (closure), при этом
val-переменные захватываются по значению,var— по ссылке (через обёртку вRef).
Если лямбда — последний аргумент функции, её можно вынести за скобки:
list.map { it.length }
list.filter { it.startsWith("A") }.sortedBy { it }
Если функция принимает ровно одну лямбду, скобки можно опустить полностью.
8.3. it — неявный параметр
В лямбде с одним параметром можно использовать неявное имя it:
list.map { it.uppercase() }
Это не глобальная переменная — it вводится локально и привязан к единственному параметру. При вложенных лямбдах внешний it недоступен — нужно явно именовать параметры.
8.4. Inline-функции
Передача лямбд в обычные функции создаёт анонимные классы (на JVM) — по одному на вызов. Это может быть дорого при частых вызовах (например, в циклах или коллекциях).
Решение — inline:
inline fun <T> measureTime(block: () -> T): T {
val start = System.nanoTime()
val result = block()
val end = System.nanoTime()
println("Время: ${(end - start) / 1_000_000} мс")
return result
}
При компиляции вызов measureTime { ... } заменяется встроенным кодом тела функции с подстановкой лямбды — как макрос, но с проверкой типов. Это устраняет аллокацию объекта и вызов виртуального метода.
Ограничения:
inline-функции не могут вызываться рекурсивно;- Лямбды внутри
inlineнельзя сохранять в поля (val f = block— ошибка), так как они не существуют как объекты во время выполнения; - Для таких случаев используется
noinlineилиcrossinline.
crossinline позволяет передавать лямбду в другую inline-функцию, запрещая в ней return из внешней функции — это важно для корректной работы с корутинами и коллбэками.
9. Обобщения (Generics)
Kotlin наследует систему обобщений от Java, но устраняет ряд неудобств через variance-аннотации и reified-типы.
9.1. Проблема неизменяемости обобщённых типов
В Java List<String> не является подтипом List<Object> — несмотря на то, что String — подтип Object. Это называется инвариантностью. В Kotlin это выражается явно:
interface MutableList<T> // инвариантный — можно и читать, и писать
interface List<out T> // ковариантный — только читать
interface Comparable<in T> // контравариантный — только писать
out T— «производитT» (например,get()возвращаетT), но не принимаетTв параметрах. Такой тип можно безопасно привести вверх:List<String>→List<Any>допустимо.in T— «потребляетT» (например,compareTo(T)), но не возвращаетT. Приведение вниз:Comparable<Any>→Comparable<String>безопасно.
Пример:
fun printAll(strings: List<String>) {
// List<out String> — ковариантен
val anys: List<Any> = strings // OK
}
Это позволяет писать более гибкие API без ? extends T / ? super T (wildcards), которые в Java усложняют сигнатуры.
9.2. Reified-типы
Из-за type erasure в JVM обобщённый тип недоступен во время выполнения. Kotlin частично обходит это ограничение с помощью reified:
inline fun <reified T> List<*>.filterIsInstance(): List<T> {
return this.filter { it is T } as List<T>
}
val numbers = list.filterIsInstance<Int>()
Только inline-функции могут иметь reified-параметры. При встраивании компилятор подставляет конкретный тип (Int), и it is T становится it is Int — безопасной проверкой.
Это широко используется в сериализации, DI-контейнерах, тестовых фреймворках.
10. Делегирование
Делегирование — один из ключевых принципов проектирования («предпочитай композицию наследованию»). Kotlin встраивает его на синтаксический уровень.
10.1. Делегирование классов через by
interface Printer {
fun print(message: String)
}
class ConsolePrinter : Printer {
override fun print(message: String) = println(message)
}
class LoggingPrinter(val impl: Printer) : Printer by impl {
override fun print(message: String) {
println("[LOG] $message")
impl.print(message)
}
}
Здесь LoggingPrinter делегирует все методы Printer экземпляру impl, кроме print, который переопределён. Это устраняет необходимость в ручном пробрасывании методов.
10.2. Делегированные свойства через by
Синтаксис val/var name: Type by Delegate() позволяет вынести логику чтения/записи в отдельный объект — делегат.
Стандартные делегаты:
lazy { ... }— отложенная инициализация (thread-safe по умолчанию):val config by lazy { loadConfig() }Delegates.observable(initial) { prop, old, new -> ... }— уведомление при изменении:var name: String by Delegates.observable("Anonymous") { _, old, new ->
println("Имя изменено: $old → $new")
}Delegates.vetoable { ... }— возможность отменить присвоение;Delegates.notNull()— для non-null свойств, инициализируемых позже (альтернативаlateinit varдля не-варов).
Кастомный делегат реализует интерфейсы ReadOnlyProperty или ReadWriteProperty. Это позволяет создавать DSL для конфигурации, кэширования, привязки к UI и т.д.
Пример: делегат для преобразования строки в целое:
class IntDelegate {
private var value: Int = 0
operator fun getValue(thisRef: Any?, property: KProperty<*>): Int = value
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
value = newValue.toInt()
}
}
var strAsInt: Int by IntDelegate()
strAsInt = "42" // → 42
Делегирование — механизм композиции поведения, интегрированный в модель свойств.
11. Корутины
Корутины — синтаксическая и семантическая конструкция, встроенная в язык через ключевое слово suspend. Они позволяют писать асинхронный код в последовательном стиле, избегая вложенности коллбэков («callback hell») и сложности управления состоянием вручную.
11.1. suspend-функции: приостанавливаемые вычисления
Функция, помеченная suspend, может приостанавливать своё выполнение без блокировки потока. Это не означает, что она «работает в фоне» — приостановка происходит кооперативно: только в точках, где вызывается другая suspend-функция (например, delay(), withContext(), сетевой запрос).
suspend fun fetchData(): String {
delay(1000) // приостановка на 1 секунду — поток НЕ блокируется
return "Data"
}
suspend fun process() {
val data = fetchData() // приостановка до завершения fetchData
println(data)
}
Ключевые принципы:
suspend-функцию можно вызывать только из другойsuspend-функции или из корутинной области (scope);- При компиляции
suspend-функция преобразуется в конечный автомат (state machine), где каждая приостановка — переход между состояниями; - Нет создания потоков «на лету» — корутины легковесны (одна корутина ≈ несколько десятков байт), и тысячи могут работать параллельно на одном потоке.
11.2. Корутинные области и построители (launch, async, runBlocking)
Для запуска корутин требуется область (CoroutineScope), определяющая жизненный цикл и контекст выполнения.
-
launch— запускает корутину «в фоне», возвращаетJob(дескриптор для отмены):val job = GlobalScope.launch {
delay(1000)
println("Завершено")
}
job.join() // дождаться завершения -
async— запускает корутину и возвращаетDeferred<T>— отложенное значение:val deferred = async { fetchData() }
val result = deferred.await() // приостановка до результата -
runBlocking— блокирует текущий поток до завершения корутины (используется вmain, тестах):fun main() = runBlocking {
val data = async { fetchData() }
println(data.await())
}
Важно: GlobalScope следует избегать в production-коде — предпочтительны структурированные области (viewModelScope, lifecycleScope в Android, или собственные CoroutineScope), привязанные к жизненному циклу компонента. Это гарантирует, что корутины автоматически отменяются при уничтожении владельца.
11.3. Контексты и диспетчеры
Выполнение корутины происходит в контексте (CoroutineContext), который включает:
CoroutineDispatcher— определяет, на каком потоке выполняется блок:Dispatchers.Main— UI-поток (Android, Compose);Dispatchers.Default— пул потоков для CPU-нагрузки;Dispatchers.IO— пул потоков для блокирующих операций (файлы, сеть);Dispatchers.Unconfined— продолжает в том же потоке, где произошла приостановка.
Смена контекста — через withContext():
suspend fun loadAndProcess(): String = withContext(Dispatchers.IO) {
val data = fetchDataFromNetwork() // блокирующий вызов — OK в IO
data.uppercase()
} // возврат в исходный контекст
Это безопаснее, чем async(Dispatchers.IO) { ... }.await(), так как withContext() сохраняет иерархию отмены.
11.4. Flow: реактивные потоки данных
Для работы с последовательностями асинхронных значений используется Flow<T> — холодный, не имеющий состояния, отменяемый аналог RxJava Observable, но с синтаксисом, встроенным в язык.
fun numbers(): Flow<Int> = flow {
for (i in 1..3) {
delay(100)
emit(i) // отправка значения
}
}
numbers()
.map { it * 2 }
.filter { it > 3 }
.collect { println(it) } // 4, 6
Особенности:
flow { ... }— builder для созданияFlow;- Операторы (
map,filter,flatMapConcat,retry,catch) — расширения; collect— терминальная операция, запускающая поток;StateFlowиSharedFlow— горячие потоки для UI-состояний и событий.
Flow интегрируется с suspend-функциями: любой оператор может содержать приостановки, и отмена распространяется корректно.
Корутины не скрывают асинхронность — они структурируют её, делая явной и управляемой, без искажения логики программы.
12. Sealed-классы и when-полноценность
Sealed-классы — это обобщение enum, позволяющее описывать иерархию типов с конечным числом подтипов, известных на этапе компиляции. Они используются для моделирования алгебраических типов данных (ADT), особенно сумм («или»-типов).
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result()
}
Особенности:
- Все прямые подтипы должны быть объявлены в том же файле;
- Подтипы могут быть
class,object,data class; - Наследование от sealed-класса закрыто — нельзя расширить его извне.
Главная ценность — полный when без else:
fun handleResult(result: Result) = when (result) {
is Result.Success -> println("Данные: ${result.data}")
is Result.Error -> println("Ошибка: ${result.message}")
Result.Loading -> println("Загрузка...")
// else не требуется — компилятор знает все случаи
}
Если добавить новый подтип (data class Timeout : Result()), компилятор укажет на все неполные when — это гарантирует, что изменение модели состояния не приведёт к неперехваченным веткам.
Sealed-классы применяются:
- Для представления состояний экрана (
Idle,Loading,Content,Error); - Для обработки результатов API-вызовов;
- В парсерах, интерпретаторах, конечных автоматах.
Это синтаксическая поддержка defensive programming через систему типов.
13. Объектные выражения и companion-объекты
В Kotlin отсутствует ключевое слово static. Вместо этого используются:
13.1. object-выражения и object-декларации
-
Объектное выражение создаёт анонимный класс с единственным экземпляром — аналог
new Runnable() { ... }в Java:val comparator = object : Comparator<String> {
override fun compare(a: String, b: String) = a.length - b.length
} -
Объектная декларация — singleton с именем:
object Constants {
const val PI = 3.14159
val DATABASE_URL = "jdbc:..."
}Обращение:
Constants.PI. Экземпляр создаётся при первом обращении (lazy, thread-safe).
13.2. companion object — «почти статика»
Класс может содержать companion object — объект, привязанный к классу и доступный через имя класса:
class User private constructor(val id: Int) {
companion object {
fun fromId(id: Int): User = User(id)
const val MAX_ID = 1000
}
}
val user = User.fromId(42) // вызов «статического» метода
Особенности:
companion objectможет реализовывать интерфейсы;- Члены с
constили@JvmFieldкомпилируются вstatic finalполя на JVM; - Для совместимости с Java используются аннотации:
Тогда
companion object {
@JvmStatic fun create() = User()
@JvmField val VERSION = "1.0"
}User.create()иUser.VERSIONработают как обычныеstatic-члены.
Это осознанный дизайн: singleton-экземпляр с интерфейсом, а не глобальное состояние без типа.
14. Взаимодействие с Java
Kotlin полностью совместим с Java бинарно и исходно. Однако различия в синтаксисе требуют механизмов сглаживания:
14.1. Платформенные типы (T!)
При вызове Java-кода компилятор Kotlin сталкивается с неизвестной null-семантикой. Результат — платформенный тип (String!), который может использоваться как String или String?. Это временный тип, разрешаемый при присвоении:
val s: String = javaMethod() // OK, если javaMethod() != null во время выполнения
val s: String? = javaMethod() // всегда безопасно
Для точного управления используются аннотации Java (@Nullable, @NonNull), которые Kotlin учитывает.
14.2. Аннотации для совместимости
@JvmStatic— метод вcompanion objectстановитсяstatic;@JvmOverloads— генерирует перегрузки для параметров по умолчанию;@JvmName— задаёт имя метода на JVM (полезно при конфликтах сигнатур);@Throws— указывает проверяемые исключения (для совместимости с Java-контрактом);@JvmField— делает свойство полем без геттера/сеттера.
Пример:
class StringUtils {
@JvmStatic
@JvmOverloads
fun repeat(str: String, times: Int = 3): String = str.repeat(times)
}
В Java: StringUtils.repeat("a"), StringUtils.repeat("a", 5).
14.3. Обработка проверяемых исключений
Kotlin не требует объявления throws и не проверяет исключения на этапе компиляции. Но при вызове Java-методов, бросающих IOException, его можно перехватить как обычно:
try {
Files.readAllBytes(path)
} catch (e: IOException) {
// обработка
}
Без @Throws Kotlin считает, что метод не бросает проверяемых исключений — это упрощает написание, но требует осторожности при интеграции.
15. Kotlin Multiplatform: expect/actual и общая логика
Kotlin Multiplatform (KMP) — архитектурная парадигма, поддерживаемая компилятором через синтаксические конструкции для разделения общего и платформенно-специфичного кода.
15.1. expect и actual: договор о реализации
В общем модуле (commonMain) объявляется ожидаемая сущность через expect:
expect class PlatformLogger() {
fun log(message: String)
}
expect fun getPlatformName(): String
В платформенных модулях (androidMain, iosMain, jvmMain) — её реализация через actual:
// androidMain
actual class PlatformLogger actual constructor() {
actual fun log(message: String) {
android.util.Log.d("APP", message)
}
}
actual fun getPlatformName() = "Android"
// iosMain
actual class PlatformLogger actual constructor() {
actual fun log(message: String) {
NSLog(message)
}
}
actual fun getPlatformName() = "iOS"
Особенности:
expectможет применяться к классам, функциям, свойствам, аннотациям;actual-реализация должна точно соответствовать сигнатуреexpect(включая модификаторыopen,suspend);actualможет использовать платформенные API (Android SDK, iOS Foundation), недоступные вcommon.
Это статическая композиция: на этапе линковки выбирается нужная реализация. Ошибки несоответствия обнаруживаются на этапе компиляции.
15.2. Ограничения и обходные пути
-
В
commonнельзя использовать платформенные типы напрямую → решения:- Абстракция через интерфейсы +
expect-фабрики; @OptionalExpectation— пометка, что реализация может отсутствовать;expect objectдля singleton-сервисов.
- Абстракция через интерфейсы +
-
actualне может расширятьexpect class→ вместо этого используется композиция. -
Для общих DTO используется
@Serializable(см. ниже) или простыеdata classвcommon.
KMP позволяет выносить до 80–90% бизнес-логики в общий код, оставляя платформенный слой тонким и тестируемым.
16. Kotlin/JS: синтаксис для мира JavaScript
При компиляции в JavaScript синтаксис Kotlin адаптируется под экосистему JS, сохраняя семантику, но добавляя механизмы взаимодействия.
16.1. Внешние объявления (external)
Для использования существующих JS-библиотек объявляются external сущности:
external fun fetch(url: String): Promise<Response>
external class Response {
fun json(): Promise<dynamic>
}
external val window: Window
external — это «доверенное» объявление: компилятор предполагает, что символ существует во время выполнения. Ошибки проявятся только в runtime — поэтому часто используются вместе с инструментами вроде kotlinx.js или генераторами из .d.ts.
16.2. dynamic — обход системы типов для совместимости
Тип dynamic отключает проверку типов во время компиляции:
val obj: dynamic = getObjectFromJS()
obj.method("arg") // любой вызов разрешён
Это необходимо при работе с динамическими API (например, eval, JSON.parse), но его использование должно быть минимизировано — предпочтительны external интерфейсы.
16.3. Модульность и экспорт
-
@JsExport— делает класс/функцию доступной из JS:@JsExport
class Calculator {
fun add(a: Double, b: Double) = a + b
}В JS:
new Calculator().add(2, 3). -
@JsName— задаёт имя в JS (полезно при перегрузке или зарезервированных словах):@JsName("createUser")
fun createUser(name: String) = User(name) -
@JsModule("axios")— импорт ES-модуля:@JsModule("axios")
external val axios: Axios
Kotlin/JS сохраняет статическую типизацию там, где это возможно, и чётко локализует «дыры» в типобезопасности через external и dynamic.
17. Kotlin/Native
Kotlin/Native компилируется в машинный код (через LLVM) и работает без виртуальной машины. Его синтаксис учитывает особенности управления памятью и межпоточности.
17.1. Управление памятью
Kotlin/Native использует подсчёт ссылок с циклическим сборщиком (ARC + cycle collector), а не stop-the-world GC. Это накладывает ограничения:
- Объекты «замораживаются» (
freeze()) при передаче между потоками; - Заморожённые объекты становятся неизменяемыми и разделяемыми;
@SharedImmutable— аннотация для констант, безопасных для совместного доступа:@SharedImmutable
val PI = 3.14159
Синтаксически это проявляется в необходимости явного управления жизненным циклом в многопоточных сценариях — но компилятор помогает: попытка изменить замороженный объект вызовет InvalidMutabilityException.
17.2. C-интероперабельность
Для вызова C-кода используются extern-объявления (через cinterop):
fun main() {
memScoped {
val buffer = allocArray<ByteVar>(256)
fgets(buffer, 256, stdin)
val str = buffer.toKString()
println("Вы ввели: $str")
}
}
Генерируются Kotlin-обёртки над заголовочными файлами, где:
IntVar,ByteVar— указатели на примитивы;CPointer<T>— безопасная абстракция надvoid*;memScoped— управляет временем жизни стека.
Это не «unsafe-код» в стиле Rust — границы безопасности сохраняются через области (scope), но с явным указанием зон ответственности.
18. DSL-конструирование
Kotlin предоставляет несколько механизмов для создания внутренних DSL — API, читающихся как специализированный язык:
18.1. Infix-функции
Позволяют вызывать бинарные функции без точек и скобок:
infix fun Int.times(str: String) = str.repeat(this)
val result = 3 times "Hello " // → "Hello Hello Hello "
Используется в тестовых фреймворках (1 shouldBe 1), маршрутизации (get "/users" { ... }).
18.2. Операторные функции
Перегрузка операторов (+, -, [], invoke, ..) через operator fun:
operator fun User.plus(other: User) = User(this.id + other.id, "${this.name}, ${other.name}")
val combined = user1 + user2
Важно: перегрузка должна сохранять семантику оператора (например, + — коммутативен), иначе читаемость страдает.
18.3. @DslMarker: защита от неправильного вложения
Аннотация предотвращает случайный выход из контекста DSL:
@DslMarker
annotation class HtmlTagMarker
@HtmlTagMarker
abstract class Tag {
abstract fun render(): String
}
class Body : Tag() {
override fun render() = "<body>...</body>"
}
// Внутри HtmlDsl нельзя вызывать методы вне текущего тега
Это гарантирует, что в html { body { div { ... } } } нельзя «выпрыгнуть» из div в html напрямую — повышает безопасность DSL.
19. Аннотации как расширения языка
Аннотации в Kotlin — не просто метаданные. Они активно участвуют в компиляции и позволяют встраивать новые возможности без изменения синтаксиса ядра.
19.1. @Serializable: сериализация как часть типа
@Serializable
data class User(val id: Int, val name: String)
val json = Json.encodeToString(User(1, "Alice"))
val user = Json.decodeFromString<User>(json)
Плагин компиляции генерирует сериализатор на этапе компиляции — без reflection, быстро и безопасно. Поддерживает JSON, ProtoBuf, CBOR.
19.2. @OptIn и @RequiresOptIn: контролируемое использование экспериментальных API
Разработчик API может пометить функцию как экспериментальную:
@RequiresOptIn(message = "Эта функция может измениться")
annotation class ExperimentalIo
@ExperimentalIo
suspend fun experimentalRead() = ...
Пользователь должен явно согласиться:
@OptIn(ExperimentalIo::class)
suspend fun load() = experimentalRead()
Это предотвращает случайное использование unstable-кода и даёт понятную миграционную траекторию.
19.3. @Deprecated: эволюция API с контролем
Расширенная версия @Deprecated из Java:
@Deprecated(
message = "Используйте processAsync",
replaceWith = ReplaceWith("processAsync()", "import suspend processAsync"),
level = DeprecationLevel.ERROR
)
fun process() { ... }
Компилятор может:
- Предупреждать (
WARNING); - Запрещать использование (
ERROR); - Автоматически предлагать замену (в IDE).
Это инструмент ответственного рефакторинга.